Skip to content

fix(env): wire shared secrets through to per-app runtime payloads#24

Closed
cooper (czxtm) wants to merge 4 commits intomainfrom
fix/wire-shared-runtime-env
Closed

fix(env): wire shared secrets through to per-app runtime payloads#24
cooper (czxtm) wants to merge 4 commits intomainfrom
fix/wire-shared-runtime-env

Conversation

@czxtm
Copy link
Copy Markdown
Contributor

Problem

Joining the waitlist (and every other tRPC call) on production returned HTTP 500:

$ curl -s -X POST 'https://stackpanel.com/api/trpc/waitlist.join?batch=1' \
    -H 'Content-Type: application/json' \
    -d '{"0":{"json":{"email":"test@example.com"}}}'

[{"error":{"json":{"message":"You are using the default secret. Please set
`BETTER_AUTH_SECRET` in your environment variables or pass `secret` in your
auth config.","code":-32603,"data":{"code":"INTERNAL_SERVER_ERROR", ...}}}}]

Root cause

.stack/config.apps.nix:envs.shared declared BETTER_AUTH_SECRET and
POLAR_ACCESS_TOKEN without a sops: source — only required = false and
a description. The codegen rendered them as "" in
packages/gen/env/data/<env>/<app>.sops.json and the embedded runtime
payload, so at request time process.env.BETTER_AUTH_SECRET === "". Better-
auth's createAuthContext falls back to its hard-coded sentinel
"better-auth-secret-12345678901234567890" when its env is empty, then
validateSecret() throws on every request — createTRPCContext calls
auth.api.getSession() for every procedure (including the public
waitlist.join mutation).

POLAR_WEBHOOK_SECRET, POLAR_PRO_PRODUCT_ID_PRODUCTION, and
POLAR_FREE_PRODUCT_ID_PRODUCTION were missing from envs.shared entirely
— only present in the deploy scope — so any process.env.* reader saw
undefined despite the SOPS source existing.

The actual encrypted secret has lived at /shared/better-auth-secret in
.stack/secrets/vars/shared.sops.yaml the whole time, and the deploy scope
already wired it. The runtime envs.shared block just never connected
the dots.

Change

Wire every shared env that has a corresponding SOPS source:

Env var SOPS source
BETTER_AUTH_SECRET /shared/better-auth-secret (required)
POLAR_ACCESS_TOKEN /shared/polar-access-token
POLAR_WEBHOOK_SECRET /shared/polar-webhook-secret
POLAR_PRO_PRODUCT_ID_PRODUCTION /shared/polar-pro-product-id-production
POLAR_FREE_PRODUCT_ID_PRODUCTION /shared/polar-free-product-id-production

BETTER_AUTH_URL, CORS_ORIGIN, POLAR_SUCCESS_URL stay required = false
without a SOPS source — they are per-env URL config and the consumer code
already handles missing values gracefully (better-auth derives the URL from
the request host; CORS_ORIGIN/POLAR_SUCCESS_URL fall back to upstream
defaults). Documented in the comment block.

Re-ran stackpanel codegen build; every per-app per-env runtime payload
now embeds real SOPS ciphertext for these keys (verified via
sops -d packages/gen/env/data/prod/web.sops.json).

Verification

Local:

$ sops --output-type json -d packages/gen/env/data/prod/web.sops.json | head -3
{
    "BETTER_AUTH_SECRET": "YreioaoJEpjD7YVEpVVarnhX2EAVoZYMDHpwqWz4gu4=",
    "BETTER_AUTH_URL": "",

Codegen idempotent — stackpanel codegen build re-runs cleanly with no
follow-up writes (codegen-drift gate should pass).

Test plan

  • Deploy Web workflow succeeds on the PR preview.
  • secrets-codegen-check (drift gate) passes.
  • POST https://web.<pr>.stackpanel.com/api/trpc/waitlist.join returns
    {"result": {"data": {"json": {"ok": true, ...}}}} instead of the
    default-secret error.
  • After merge, same call against https://stackpanel.com/api/trpc/waitlist.join
    returns success.

Refs stackpanel-3tj.

Joining the waitlist (and every other tRPC call) on production returned
HTTP 500 with `You are using the default secret. Please set BETTER_AUTH_SECRET ...`.

Root cause: `.stack/config.apps.nix:envs.shared` declared
`BETTER_AUTH_SECRET` and `POLAR_ACCESS_TOKEN` without a `sops:` source —
just `required = false` and a description. The codegen rendered
`"BETTER_AUTH_SECRET": ""` into `packages/gen/env/data/<env>/<app>.sops.json`
and the embedded runtime payload, so at request time
`process.env.BETTER_AUTH_SECRET === ""`. Better-auth fell back to its hard-
coded sentinel `"better-auth-secret-12345678901234567890"` and `validateSecret()`
threw on every request, because `createTRPCContext` calls
`auth.api.getSession()` for every procedure (waitlist included).

`POLAR_WEBHOOK_SECRET`, `POLAR_PRO_PRODUCT_ID_PRODUCTION`, and
`POLAR_FREE_PRODUCT_ID_PRODUCTION` were missing from `envs.shared`
entirely — only present in the deploy scope — so any `process.env.*`
reader saw `undefined` despite the SOPS source existing.

Fix wires every shared env that has a corresponding SOPS source:

  BETTER_AUTH_SECRET                  → /shared/better-auth-secret  (required)
  POLAR_ACCESS_TOKEN                  → /shared/polar-access-token
  POLAR_WEBHOOK_SECRET                → /shared/polar-webhook-secret
  POLAR_PRO_PRODUCT_ID_PRODUCTION     → /shared/polar-pro-product-id-production
  POLAR_FREE_PRODUCT_ID_PRODUCTION    → /shared/polar-free-product-id-production

`BETTER_AUTH_URL`, `CORS_ORIGIN`, `POLAR_SUCCESS_URL` stay `required = false`
without a SOPS source — they are per-env URL config and the consumer code
already handles missing values gracefully (better-auth derives the URL
from the request host; CORS_ORIGIN and POLAR_SUCCESS_URL fall back to
upstream defaults). Documented in the comment block.

Re-ran `stackpanel codegen build` — every per-app per-env runtime payload
now embeds real SOPS ciphertext for these keys (verified via
`sops -d packages/gen/env/data/prod/web.sops.json`). codegen-drift gate
should pass.

Refs stackpanel-3tj.
@cursor
Copy link
Copy Markdown

cursor Bot commented May 1, 2026

PR Summary

Medium Risk
Touches environment/secret wiring and regenerates encrypted payload artifacts; misconfiguration could break app startup/auth/billing integrations, but the changes are largely declarative/codegen-driven.

Overview
Fixes shared runtime env generation by adding SOPS bindings for BETTER_AUTH_SECRET and Polar secrets/IDs in .stack/config.apps.nix (and marking BETTER_AUTH_SECRET required), ensuring these values are present in per-app runtime payloads.

Regenerates @gen/env outputs to include the new variables across api/docs/web: updates docs, embedded metadata, Effect schemas to treat secrets as redacted, and updates generated .sops.json + runtime payloads to carry ciphertext (rather than "").

Adds package.json.backup patterns to .gitignore.

Reviewed by Cursor Bugbot for commit 8a7897c. Configure here.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 1, 2026

Preview deployed to pr-24https://pr-24.stackpanel.com

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 1, 2026

Docs preview deployed to pr-24

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Optional secrets typed as required in Effect schemas
    • Updated the Effect schemas for api/docs/web so the four optional Polar secrets are now Schema.optional(Schema.RedactedFromValue(Schema.String)) and no longer fail decode when absent.

Create PR

Or push these changes by commenting:

@cursor push 926aaaaa88
Preview (926aaaaa88)
diff --git a/packages/gen/env/src/effect/api.ts b/packages/gen/env/src/effect/api.ts
--- a/packages/gen/env/src/effect/api.ts
+++ b/packages/gen/env/src/effect/api.ts
@@ -21,11 +21,17 @@
   BETTER_AUTH_SECRET: Schema.RedactedFromValue(Schema.String),
   BETTER_AUTH_URL: Schema.optional(Schema.String),
   CORS_ORIGIN: Schema.optional(Schema.String),
-  POLAR_ACCESS_TOKEN: Schema.RedactedFromValue(Schema.String),
-  POLAR_FREE_PRODUCT_ID_PRODUCTION: Schema.RedactedFromValue(Schema.String),
-  POLAR_PRO_PRODUCT_ID_PRODUCTION: Schema.RedactedFromValue(Schema.String),
+  POLAR_ACCESS_TOKEN: Schema.optional(Schema.RedactedFromValue(Schema.String)),
+  POLAR_FREE_PRODUCT_ID_PRODUCTION: Schema.optional(
+    Schema.RedactedFromValue(Schema.String),
+  ),
+  POLAR_PRO_PRODUCT_ID_PRODUCTION: Schema.optional(
+    Schema.RedactedFromValue(Schema.String),
+  ),
   POLAR_SUCCESS_URL: Schema.optional(Schema.String),
-  POLAR_WEBHOOK_SECRET: Schema.RedactedFromValue(Schema.String),
+  POLAR_WEBHOOK_SECRET: Schema.optional(
+    Schema.RedactedFromValue(Schema.String),
+  ),
   PORT: Schema.String,
   POSTGRES_URL: Schema.RedactedFromValue(Schema.String),
 }) {}

diff --git a/packages/gen/env/src/effect/docs.ts b/packages/gen/env/src/effect/docs.ts
--- a/packages/gen/env/src/effect/docs.ts
+++ b/packages/gen/env/src/effect/docs.ts
@@ -21,11 +21,17 @@
   BETTER_AUTH_SECRET: Schema.RedactedFromValue(Schema.String),
   BETTER_AUTH_URL: Schema.optional(Schema.String),
   CORS_ORIGIN: Schema.optional(Schema.String),
-  POLAR_ACCESS_TOKEN: Schema.RedactedFromValue(Schema.String),
-  POLAR_FREE_PRODUCT_ID_PRODUCTION: Schema.RedactedFromValue(Schema.String),
-  POLAR_PRO_PRODUCT_ID_PRODUCTION: Schema.RedactedFromValue(Schema.String),
+  POLAR_ACCESS_TOKEN: Schema.optional(Schema.RedactedFromValue(Schema.String)),
+  POLAR_FREE_PRODUCT_ID_PRODUCTION: Schema.optional(
+    Schema.RedactedFromValue(Schema.String),
+  ),
+  POLAR_PRO_PRODUCT_ID_PRODUCTION: Schema.optional(
+    Schema.RedactedFromValue(Schema.String),
+  ),
   POLAR_SUCCESS_URL: Schema.optional(Schema.String),
-  POLAR_WEBHOOK_SECRET: Schema.RedactedFromValue(Schema.String),
+  POLAR_WEBHOOK_SECRET: Schema.optional(
+    Schema.RedactedFromValue(Schema.String),
+  ),
   PORT: Schema.String,
   POSTGRES_URL: Schema.RedactedFromValue(Schema.String),
 }) {}

diff --git a/packages/gen/env/src/effect/web.ts b/packages/gen/env/src/effect/web.ts
--- a/packages/gen/env/src/effect/web.ts
+++ b/packages/gen/env/src/effect/web.ts
@@ -22,11 +22,17 @@
   BETTER_AUTH_URL: Schema.optional(Schema.String),
   CORS_ORIGIN: Schema.optional(Schema.String),
   HOSTNAME: Schema.String,
-  POLAR_ACCESS_TOKEN: Schema.RedactedFromValue(Schema.String),
-  POLAR_FREE_PRODUCT_ID_PRODUCTION: Schema.RedactedFromValue(Schema.String),
-  POLAR_PRO_PRODUCT_ID_PRODUCTION: Schema.RedactedFromValue(Schema.String),
+  POLAR_ACCESS_TOKEN: Schema.optional(Schema.RedactedFromValue(Schema.String)),
+  POLAR_FREE_PRODUCT_ID_PRODUCTION: Schema.optional(
+    Schema.RedactedFromValue(Schema.String),
+  ),
+  POLAR_PRO_PRODUCT_ID_PRODUCTION: Schema.optional(
+    Schema.RedactedFromValue(Schema.String),
+  ),
   POLAR_SUCCESS_URL: Schema.optional(Schema.String),
-  POLAR_WEBHOOK_SECRET: Schema.RedactedFromValue(Schema.String),
+  POLAR_WEBHOOK_SECRET: Schema.optional(
+    Schema.RedactedFromValue(Schema.String),
+  ),
   PORT: Schema.String,
   POSTGRES_URL: Schema.RedactedFromValue(Schema.String),
 }) {}

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 8a7897c. Configure here.

POLAR_FREE_PRODUCT_ID_PRODUCTION: Schema.RedactedFromValue(Schema.String),
POLAR_PRO_PRODUCT_ID_PRODUCTION: Schema.RedactedFromValue(Schema.String),
POLAR_SUCCESS_URL: Schema.optional(Schema.String),
POLAR_WEBHOOK_SECRET: Schema.RedactedFromValue(Schema.String),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional secrets typed as required in Effect schemas

Medium Severity

POLAR_ACCESS_TOKEN, POLAR_FREE_PRODUCT_ID_PRODUCTION, POLAR_PRO_PRODUCT_ID_PRODUCTION, and POLAR_WEBHOOK_SECRET changed from Schema.optional(Schema.String) to Schema.RedactedFromValue(Schema.String), making them required for schema decoding. But the nix config declares all four with required = false and their descriptions explicitly state they handle missing values ("When unset, polarClient is null", "Falls back to the sandbox product when unset"). If the Effect-based env loader is used in an environment where SOPS decryption fails or values are empty, schema decode will reject the payload instead of allowing graceful degradation.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8a7897c. Configure here.

The web Worker doesn't call loadAppEnv() at request time; it relies on
Cloudflare to inject env vars set on the deployed script. Previously only
DATABASE_URL was forwarded — BETTER_AUTH_SECRET and the four Polar
secrets we just SOPS-wired were not, so process.env.BETTER_AUTH_SECRET
was empty in production, better-auth fell back to its sentinel, and
every tRPC call (including waitlist.join) returned 500.

This forwards the five secrets from process.env (populated by
loadDeployEnv at the top of alchemy.run.ts) into the Cloudflare.Vite
env: map. Polar values default to '' so a missing-secret deploy still
boots; consumer code treats empty as feature-disabled.

Refs stackpanel-3tj.
…time forwarding

This reverses the env-shovel approach from 21c0084 in favour of decrypting
the embedded `@gen/env` SOPS payload at Worker boot. The Worker now needs
only `SOPS_AGE_KEY` (the AGE key material) and `APP_ENV` (the SOPS
namespace discriminator) at deploy time; every other secret is unsealed
on first request from the encrypted payload already shipped in
packages/gen/env/src/runtime/generated-payloads/web/{dev,staging,prod}.ts.

Why:
- Single source of truth. Adding a new app secret used to require two
  edits (.stack/config.apps.nix AND apps/web/alchemy.run.ts); now only
  the Nix scope edit + a codegen rebuild is needed.
- No dual-write into Cloudflare's secret store. The encrypted payload is
  the only place secret material lives.
- Mirrors the Fly-deployed apps/api boot pattern.

Changes:
- apps/web/src/server.ts: top-level `await loadAppEnv("web", APP_ENV,
  { inject: true })` against the new edge-safe `@gen/env/runtime/edge`
  loader. Guarded by `process.env.SOPS_AGE_KEY` so vite dev / vitest
  keep working with whatever process.env they already have.
- apps/web/alchemy.run.ts: drop BETTER_AUTH_SECRET and the four POLAR_*
  forwards added in 21c0084. Add SOPS_AGE_KEY (read from process.env
  after `loadDeployEnv`) and APP_ENV (the resolved appEnv literal).
- packages/auth/src/index.ts: lazify `betterAuth({...})` behind a
  Proxy-backed `auth` export. Defers `validateSecret` to first property
  access (per-request) so the import chain
  routeTree.gen.ts → routes/api/trpc.$.ts → @stackpanel/auth no longer
  crashes when env injection hasn't happened yet. Adds `getAuth()` for
  callers that want to surface init errors eagerly.
- nix/stackpanel/lib/codegen/env-package.nix: add `./runtime/edge`
  export pointing at `loader.ts` (no FileSystem/ChildProcess deps).
  Required because the existing `./runtime` export resolves to
  `node-loader.ts`, which pulls in @effect/platform-node — fine for
  alchemy.run.ts on Node, broken in a Cloudflare Worker.
- packages/gen/env/package.json: regenerated from the Nix change.
- docs/adr/0001-runtime-secrets-via-gen-env-loader.md (+ README): ADR
  documenting the decision, consequences, and rejected alternatives.

Refs: bd stackpanel-3tj
Move the SOPS payload decrypt from `apps/web/src/server.ts` into
`packages/auth/src/index.ts` so it happens BEFORE `betterAuth({...})`
constructs the auth instance — and before `polarClient` is built.

The previous PR-24 attempt put the load in `server.ts` with a Proxy in
`@stackpanel/auth`, but ESM hoisting meant `payments.ts` (and the rest of
`index.ts`'s static imports) evaluated before the `server.ts` TLA ran:
`polarClient` was always `null` in the Worker, and `betterAuth({...})`
was being initialised before `process.env.BETTER_AUTH_SECRET` had been
written by `loadAppEnv`. Result: HTTP 500 "you are using the default
secret" on every tRPC call.

Now `@stackpanel/auth/index.ts`:

- Awaits `loadAppEnv("web", APP_ENV, { inject: true })` at the top of
  the module (gated on `process.env.SOPS_AGE_KEY`).
- Reads `BETTER_AUTH_SECRET` and `POLAR_*` via a local `envOf()` helper
  that prefers the decrypted payload over `process.env` (so we don't
  rely on `process.env` being writable at the edge — Cloudflare's
  unenv shim is mutable today, but it shouldn't be load-bearing).
- Constructs `polarClient` inline in `index.ts` AFTER the TLA, instead
  of importing it from `./lib/payments`. The static import was the
  reason `polarClient` was always null in the Worker.
- Drops the lazy Proxy: now that the env load happens inside this
  module, eager construction is safe again.

`apps/web/src/server.ts` keeps its own (defense-in-depth) TLA load —
both calls are idempotent because the loader caches the decrypted
payload.

Refs ADR docs/adr/0001-runtime-secrets-via-gen-env-loader.md.
@czxtm
Copy link
Copy Markdown
Contributor Author

Superseded by #26.

#26 keeps your SOPS-source wiring (so the codegen still embeds real ciphertext for BETTER_AUTH_SECRET + the four Polar secrets) but reverts the runtime-loader pivot (commits 7f83faa8 and 51e65bfc) in favour of build-time env injection — secrets are forwarded into Cloudflare.Vite({ env }) so Workers boot with process.env already populated, no per-isolate SOPS decrypt cost.

The new ADR 0003 there documents the trade-off; ADR 0001's body is preserved with a 'Superseded by 0003' header for the historical record.

Suggest closing this PR once you're happy with #26.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant